<?php

class Storage implements ReadableStorage, WritableStorage {

  const SAVE_INSERT = -1;
  const SAVE_UPDATE = 1;

  public static $sql;
  public static $counter = [];
  public static $forceMaster = false;

  const READONLY		= 'read';
  const WRITABLE		= 'write';

  protected $db;
  protected $table;
  protected $columns;
  protected $key;
  protected $keys;

  public function __construct($config) {
    $this->db = isset($config['db']) ? $config['db'] : 'matcha';
    $this->table = $config['table'];
    $this->columns = $config['columns'];
    $this->key = $config['key'];
    if (isset($config['keys'])) {
      $this->keys = $config['keys'];
    }
  }

  public function load(Array $ids) {
    if (!$ids) return array();

    $sql = 'SELECT ' . $this->getColumnAlias() .
      ' FROM `' . $this->table . '`' .
      $this->idQuery($ids);
    $result = $this->query($sql);

    $rows = [];
    while ($row = $result->fetch_assoc()) {
      $rows[$row[$this->key]] = $this->parseRow($row);
    }
    $result->free();
    return $rows;
  }

  public function loadBy($key, $value) {
    if (!$this->keys || !in_array($key, $this->keys)) {
      throw new Exception("could not load by '{$key}' of {$this->table} which is not in the 'keys' list");
    }

    $db_key = $this->columns[$key];
    $sql = 'SELECT ' . $this->getColumnAlias() .
      ' FROM `' . $this->table . '` WHERE `' . $db_key . '`="' . $value . '" LIMIT 1';

    $result = $this->query($sql);
    $row = $result->fetch_assoc();
    if ($row) $row = $this->parseRow($row);

    $result->free();
    return $row;
  }

  public function find(BaseQuery $query, Array $opts = array()) {
    return $this->multi_fetch("`{$this->columns[$this->key]}` AS `id`", $query, $opts);
  }

  public function multi_fetch($field, BaseQuery $query, Array $opts = array()) {
    return array_map(function ($o) {return current($o);}, $this->raw_fetch($field, $query, $opts));
  }

  public function raw_fetch($field, BaseQuery $query, Array $opts = array()) {
    list($where, $order, $limit, $group, $index) = $this->cause($query, $opts);
    $sql = "SELECT {$field} " .
      "FROM {$this->table} {$index} {$where} {$group} {$order} {$limit}";
    $rows = [];
    if ($res = $this->query($sql)) {
      $rows = $this->fetch_all($res);
    }

    $res->free();
    return $rows;
  }

  private function cause(BaseQuery $query, Array $opts = array()) {
    $qb = new MysqlQueryBuilder($query, $this->columns);
    $where = "WHERE " . $qb->query();

    $order = '';
    if (isset($opts['sort']) && $opts['sort']) {
      $ords = array();
      foreach ($opts['sort'] as $field => $order) {
        $sortField = isset($this->columns[$field]) ? "`{$this->columns[$field]}`" : $field;
        $ords[] = "{$sortField} {$order}";
      }
      $order = "ORDER BY " . join(', ', $ords);
    }

    if (!isset($opts['size'])) $opts['size'] = 10;
    if (!isset($opts['from'])) $opts['from'] = 0;
    $limit = isset($opts['nolimit']) ? '' : "LIMIT {$opts['from']},{$opts['size']}";

    $group = '';
    if (isset($opts['group']) && $opts['group']) {
      $group = "GROUP BY {$opts['group']}";
    }

    $index = isset($opts['index']) ? " FORCE INDEX ({$opts['index']}) " : '';

    return array($where, $order, $limit, $group, $index);
  }

  public function save(Array $data, $flag) {
    $columns = $values = $attributes = array();

    foreach ($data as $key => $value) {
      if (isset($this->columns[$key])) {
        $columns[] = $this->columns[$key];
        $values[] = self::escape($value);
      } else {
        //todo: no escape before buildAttribute!!! add testcase!!
        $attributes[$key] = $value;
      }
    }

    if (isset($this->columns['attributeData'])) {
      $columns[] = $this->columns['attributeData'];
      $values[] = $this->buildAttribute($attributes);
    }

    if ($flag == self::SAVE_INSERT) {
      $id = $this->insert($columns, $values);
    //if (isset($data[$this->key]) && $data[$this->key] && $flag != self::SAVE_INSERT) {
    } else {
      $id = $this->update($columns, $values);
    }

    return strval($id);
  }

  public function totalCount(BaseQuery $query, Array $opts = array()) {
    list($where, $order, $limit, $group) = $this->cause($query, $opts);
    $limit = $opts ? $limit : '';
    $sql = "SELECT COUNT(1) FROM `{$this->table}` {$where} {$limit}";
    $result = $this->query($sql);
    return intval(current($result->fetch_assoc()));
  }

  public function delete(Array $ids) {
    if (!array_filter($ids)) return 0;
    $sql = 'DELETE FROM `' . $this->table . '`' . $this->idQuery($ids);
    return $this->query($sql);
  }

  public function quick_delete(BaseQuery $query, Array $opts = array()) {
    list($where, $order, $limit) = $this->cause($query, $opts);
    if (!isset($opts['size']) && !isset($opts['from'])) $limit = '';

    $sql = "DELETE FROM `{$this->table}` {$where} {$limit}";
    if (($res = $this->query($sql))) {
      $conn = self::getConnection($this->db, self::WRITABLE);
      return $conn->affected_rows;
    }
    return $res;
  }

  public function quick_update(BaseQuery $query, Array $new_data, Array $opts = array()) {
    list($where, $order, $limit) = $this->cause($query, $opts);
    if (!isset($opts['size']) && !isset($opts['from'])) $limit = '';

    $sets = array();
    foreach ($new_data as $object_key => $value) {
      if (!isset($this->columns[$object_key])) {
        throw new Exception('Could not update unknown field:' . $object_key);
      }
      if (is_null($value)){
        $sets[] = "`{$this->columns[$object_key]}` = null";
      } else {
        $sets[] = "`{$this->columns[$object_key]}` = '{$value}'";
      }
    }
    if (!$sets) throw new Exception('Must update at least one field!');
    $set_string = implode(', ', $sets);
    $sql = "UPDATE `{$this->table}` SET {$set_string} {$where} {$limit}";
    $this->query($sql);
  }

  protected function insert(Array $columns, Array $values) {
    foreach ($values as $key => $value) {
      if (is_null($value)) {
        unset($values[$key]);
        unset($columns[$key]);
      }
    }
    $column_string = '`' . implode('`, `', $columns) . '`';
    $value_string = "'" . implode("', '", $values) . "'";
    $sql = "INSERT INTO `{$this->table}` ({$column_string}) VALUES ({$value_string})";

    $res = $this->query($sql);
    $conn = self::getConnection($this->db, self::WRITABLE);

    if (!$res) throw new Exception('insert fail: ' . $sql);
    return $conn->insert_id;
  }

  protected function update(Array $columns, Array $values) {
    $id = null;
    $sets = array();
    foreach ($columns as $i => $column) {
      if ($column == $this->columns[$this->key]) {
        $id = $values[$i];
        continue;
      }
      if (is_null($values[$i])){
        $sets[] = "`{$column}` = null";
      } else {
        $sets[] = "`{$column}` = '{$values[$i]}'";
      }
    }

    if (!$id) {
      throw new Exception('update without id!');
    }

    $set_string = implode(', ', $sets);
    $sql = "UPDATE `{$this->table}` SET {$set_string} WHERE `{$this->columns[$this->key]}` = '{$id}' LIMIT 1";
    $res = $this->query($sql);
    return $id;
  }

  private function idQuery(Array $ids) {
    $key = $this->key;
    $escaped_ids = array();
    foreach ($ids as $_id){
      $escaped_ids[$_id] = self::escape($_id);
      if (!is_numeric($_id)) {
        $escaped_ids[$_id] = "'{$escaped_ids[$_id]}'";
      }
    }
    return ' WHERE `' . $this->columns[$key] . '` IN (' .
    join(',', $escaped_ids) .
    ') LIMIT ' . count($ids);
  }

  private function buildAttribute(Array $attributes) {
    $attribute = array();
    foreach ($attributes as $key => $value) {
      $value = str_replace(array('%', "\n"), array('%%', '%n'), $value);
      $attribute[] = "{$key}:{$value}";
    }
    return self::escape(join("\n", $attribute));
  }

  private function fetch_all($res, $result_type = MYSQLI_NUM) {
    if (is_bool($res)) return array();

    for ($result = array(); $tmp = $res->fetch_assoc();) {
      $result[] = $tmp;
    }
    return $result;
  }

  //为了性能考虑，parse的时候可以少很多次的数据copy
  private function getColumnAlias() {
    $string = '';
    foreach($this->columns as $obj => $db) {
      $string .= ", `$db` as `$obj`";
    }
    return trim($string, ',');
  }

  protected function parseRow($row) {
    if(isset($row['attributeData'])) {
      preg_match_all("/([^:]+):(.*)\n/", $row['attributeData'] . "\n", $matches);
      foreach ($matches[1] as $matchKey => $dbName) {
        $row[$dbName] = str_replace(array('%%', '%n'), array("%", "\n"), $matches[2][$matchKey]);
      }
      unset($matches, $row['attributeData']);
    }
    return $row;
  }

  private function query($sql) {
    $query_type = strtoupper(substr($sql, 0, 6));
    $mode = ($query_type == 'SELECT' && !self::$forceMaster) ? self::READONLY : self::WRITABLE;
    $conn = self::getConnection($this->db, $mode, $this->table);

    $startTime = microtime();

    $result = $conn->query($sql);

    if (!isset(self::$counter[$this->table][$mode])) {
      self::$counter[$this->table][$mode] = 0;
    }
    self::$counter[$this->table][$mode]++;

    if ($this->table != 'napa_log' && !IS_CLI && $this->db == 'matcha') {
      $costTime = dt($startTime, microtime());
      if ($costTime > 0.5) {
        Log::set($this->table, $costTime . ' : ' . $sql, 'slowsql');
      }
    }

    if (!$result) {
      if ($conn->errno == '2006' || $conn->errno == '2013') {	// skip 'MySQL server has gone away' error
        $conn = self::getConnection($this->config['db'], self::READONLY);	//写操作还没考虑进来
        $result = $conn->query($sql);
        if ($result === false) {
          throw new Exception("Fail to query: {$sql} \n Error: " . $conn->error);
        }
      } else {
        throw new Exception("There's something wrong with the sql! {$sql}. Error: " . $conn->error);
      }
    }

    return $result;
  }

  private static $connections = array();

  public static function closeConnections() {
    foreach (self::$connections as $conn) {
      $conn->close();
    }
  }

  /**
   * @param string $db
   * @param string $mode
   * @param string $table
   * @return resource
   */
  public static function getConnection($db, $mode = self::READONLY, $table = '') {
    $key = $db . '-' . $mode;

    $conn = NULL;

    /*
    //if this table has write before
    if ($table && isset(self::$counter[$table][self::WRITABLE]) && self::$counter[$table][self::WRITABLE] > 0 &&
      isset(self::$connections[$db . '-' . self::WRITABLE]) && self::$connections[$db . '-' . self::WRITABLE]) {
      $conn = self::$connections[$db . '-' . self::WRITABLE];
    }*/

    if (!$conn && isset(self::$connections[$key])) {
      $conn = self::$connections[$key];
    }

    if (!$conn) {
      $servers = Config::get('storage.' . $key);
      $conf = $servers[array_rand($servers)];

      $conn = mysqli_init();
      $conn->real_connect($conf['server'], $conf['user'], $conf['pass'], $db, $conf['port'], null, MYSQL_CLIENT_COMPRESS);
      $conn->set_charset('utf8mb4');	//supported by mysql 5.5.3+

      self::$connections[$key] = $conn;
    }

    // mysql::ping() is no longer working for using mysqlnd. see: https://bugs.php.net/bug.php?id=52561
    if ( isset($conn->_last) && (time() - $conn->_last) > 10 && !(@$conn->ping()) ) {
      unset(self::$connections[$key]);
      $conn = self::getConnection($db, $mode);
      //reconnect when conn lost, avoid 'MySQL server has gone away' error
    }
    $conn->_last = time();
    return $conn;
  }

  public static function escape($string) {
    $conn = self::getConnection('matcha');
    return is_null($string) ? null : $conn->real_escape_string($string);
    //return self::getConnection()->real_escape_string($string);
  }
}